switch 中的统一错误处理
Brian Goetz 于 2023 年 12 月 15 日基于 OCaml 的一些灵感,并且鉴于迄今为止对 switch 的重大升级使其能够比以前做更多的事情,我们一直在探索对 switch 的进一步改进,以将错误处理也纳入其中。
概述
增强 switch
结构以支持 case
标签,这些标签匹配在评估选择器表达式期间抛出的异常,从而提供对正常结果和异常结果的统一处理。
背景
switch
结构的目的是根据评估单个表达式(“选择器”)来选择一个行动方案。语言中并不严格需要 switch
结构;switch
所做的一切都可以通过 if-else
来完成。但是,语言包含 switch
,因为它体现了有用的约束,这些约束既简化了代码,又能够进行更全面的错误检查。
最初版本的 switch
非常有限:选择器表达式仅限于少量基本类型,case
标签仅限于数字字面量,并且 switch 的主体仅限于通过副作用进行操作(仅语句,没有表达式)。由于这些限制,switch
的使用通常仅限于解析器和状态机等低级代码。在 Java 5 和 7 中,switch
进行了小幅升级,以支持基本包装类型、枚举和字符串作为选择器,但其作为“从这些常量中选择一个”的作用并没有发生重大变化。
最近,switch
进行了更重大的升级,使其能够在日常程序逻辑中发挥更大的作用。现在,switch 可以用作表达式,而不是语句,从而实现更大的组合性和更简化的代码。选择器表达式现在可以是任何类型。switch 块中的 case
标签可以是丰富的模式,而不仅仅是常量,并且具有任意谓词作为保护。当切换到涉及密封类型的选择器时,我们获得了更丰富的类型检查以确保完整性。总而言之,这意味着可以使用 switch
来简洁可靠地表达比以前更多的程序逻辑。
将空值引入 switch
从历史上看,switch
结构对空值很敏感;如果选择器评估为 null
,则 switch
会立即以 NullPointerException
突然完成。当 switch 中唯一可以使用的引用类型是基本包装类型和枚举时,这在一定程度上是有道理的,因为空值几乎总是表示错误,但是随着 switch
变得越来越强大,这越来越与我们希望使用 switch
做的事情不匹配。开发人员被迫绕过这个问题,但这些解决方法产生了不良后果(例如,强制使用语句 switch 而不是表达式 switch)。以前,为了处理空值,必须单独评估选择器并使用 if
将其与 null
进行比较
SomeType selector = computeSelector();
SomeOtherType result;
if (selector == null) {
result = handleNull();
} else {
switch (selector) {
case X:
result = handleX();
break;
case Y:
result = handleY();
break;
}
}
这不仅更繁琐,而且更不简洁,而且违背了 switch
的主要工作,即简化“根据选择器表达式选择一条路径”的决策。结果没有得到统一处理,它们没有在一个地方得到处理,而且无法将所有这些都表达为表达式限制了与其他语言功能的组合。
在 Java 21 中,可以将 null
视为 case
子句中选择器的另一种可能值(甚至将 null
处理与 default
相结合),以便上述混乱可以简化为
SomeOtherType result = switch (computeSelector()) {
case null -> handleNull();
case X -> handleX();
case Y -> handleY();
}
这更易于阅读,不易出错,并且与语言的其余部分更好地交互。将空值统一视为另一种值,而不是将其视为带外条件,使 switch
更有用,并使 Java 代码更简单、更好。(为了兼容性,没有 case null
的 switch
在遇到空选择器时仍然会抛出 NullPointerException
;我们使用 case null
选择新的行为。)
其他 switch 技巧
switch
新功能的积累意味着它可以在我们最初意识到的更多情况下使用。其中一个用途是用布尔 switch 表达式替换三元条件表达式;现在 switch
可以支持布尔选择器,我们可以替换
```
expr ? A : B
``` with the switch expression
switch (expr) {
case true -> A;
case false -> B;
}
这可能不会立即显得更好,因为三元表达式更简洁,但 switch
肯定更清晰。
而且,如果我们在其他三元表达式的手臂中嵌套三元表达式(可能很深),这会很快变得难以阅读,而相应的嵌套 switch 即使嵌套到多个级别也仍然可读。我们不希望人们在一夜之间将所有三元表达式都更改为 switch,但我们确实希望人们会越来越多地找到布尔 switch 比三元表达式更可取的用途。(如果语言从一开始就具有布尔 switch 表达式,我们可能根本不会有三元表达式。)
另一个不太明显的例子是在“选择一条路径”的范围内使用保护来进行选择,这是 switch
的设计目的。例如,我们可以将经典的“FizzBuzz”练习写成
String result = switch (getNumber()) {
case int i when i % 15 == 0 -> "FizzBuzz";
case int i when i % 5 == 0 -> "Fizz";
case int i when i % 3 == 0 -> "Buzz";
case int i -> Integer.toString(i);
}
改进后的 switch 的一个更有争议的用途是作为块表达式的替代品。有时我们想使用表达式(例如,当将参数传递给方法时),但该值只能使用语句来构造
String[] choices = new String[2];
choices[0] = f(0);
choices[1] = f(1);
m(choices);
虽然这有点“偏离主题”,但我们可以用 switch 表达式来替换它
m(switch (0) {
default -> {
String[] choices = new String[2];
choices[0] = f(0);
choices[1] = f(1);
yield choices;
}
})
虽然这些不是我们在升级 switch
时想到的主要用例,但它说明了 switch
的改进组合如何使其成为一种“瑞士军刀”。
统一处理错误
以前,空选择器值被视为带外事件,要求用户以非统一的方式处理空选择器。Java 21 中对 switch
的改进使空值能够作为选择器值得到统一处理,就像其他值一样。
switch
中带外事件的另一个类似来源是异常;如果评估选择器抛出异常,则 switch 会立即以该异常完成。这是一个完全合理的設計選擇,但它迫使用户使用单独的机制(通常是繁琐的机制)来处理异常,就像我们处理空选择器一样
Number parseNumber(String s) throws NumberFormatException() { ... }
try {
switch (parseNumber(input)) {
case Integer i -> handleInt(i);
case Float f -> handleFloat(f);
...
}
}
catch (NumberFormatException e) {
... handle exception ...
}
这已经很不幸了,因为 switch 的设计目的是处理“根据评估选择器选择一条路径”,而“解析错误”是评估选择器的可能结果之一。能够像我们处理空值一样,以统一的方式处理错误情况和成功情况会很好。更糟糕的是,这段代码甚至没有表达我们想要的意思:catch
块不仅捕获评估选择器抛出的异常,还捕获 switch 主体抛出的异常。
为了表达我们的意思,我们需要更不幸的
var answer = null;
try {
answer = parseNumber(input);
}
catch (NumberFormatException e) {
... handle exception ...
}
if (answer != null) {
switch (answer) {
case Integer i -> handleInt(i);
case Float f -> handleFloat(f);
...
}
}
正如将 null
一致地处理为选择器表达式的一种潜在值是一个改进,我们也可以通过一致地处理正常和异常完成来获得类似的改进。正常完成和异常完成是互斥的,并且在 try-catch
中处理异常与在 switch
语句中处理正常值有很多共同之处(catch 子句实际上是与类型模式匹配)。对于具有预期故障模式的活动,通过一种机制处理成功完成,而通过另一种机制处理失败完成,会使代码更难阅读和维护。
提案
我们可以扩展 switch
以类似的方式更一致地处理异常,就像我们通过引入 throws
子句来扩展它以处理空值一样,当选择器表达式以兼容异常突然完成时,该子句会匹配。
String allTheLines = switch (Files.readAllLines(path)) {
case List<String> lines ->
lines.stream().collect(Collectors.joining("\n"));
case throws IOException e -> "";
}
这更清晰地捕捉了程序员的意图,因为预期的成功情况和预期的失败情况在同一个地方得到一致地处理,并且它们的结果可以流入 switch 表达式的结果。
case
标签的语法扩展为包含一种新形式, case throws
,后面跟着一个类型模式。
`case throws IOException e:`
异常情况可以在所有形式的 switch
中使用:表达式和语句 switch,使用传统(冒号)或单一结果(箭头)case 标签的 switch。异常情况可以像任何其他模式情况一样有保护。
异常情况与其他异常情况具有明显的支配顺序(与验证 try-catch
中 catch
子句顺序的顺序相同),并且不参与与非异常情况的支配顺序。如果异常情况指定了选择器表达式无法抛出的异常类型,或者不扩展 Throwable
的类型,则为编译时错误。为了清晰起见,异常情况应该放在所有其他非异常情况之后。
在评估 switch
语句或表达式时,会评估选择器表达式。如果选择器表达式的评估抛出异常,并且 switch
中的某个异常情况与该异常匹配,则控制权将转移到第一个与该异常匹配的异常情况。如果没有异常情况与该异常匹配,则 switch 将以该异常突然完成。
这稍微调整了 switch
抛出的异常集;如果选择器表达式抛出异常,但 switch 的主体没有抛出异常,并且它与一个不受保护的异常情况匹配,则 switch 不被认为抛出该异常。
示例
在某些情况下,我们希望通过在出现异常时提供一个回退值来对部分计算进行汇总。
Function<String, Optional<Integer>> safeParse =
s -> switch(Integer.parseInt(s)) {
case int i -> Optional.of(i);
case throws NumberFormatException _ -> Optional.empty();
};
在其他情况下,我们可能希望完全忽略异常值。
stream.mapMulti((f, c) -> switch (readFileToString(url)) {
case String s -> c.accept(s);
case throws MalformedURLException _ -> { };
});
在其他情况下,我们可能希望更一致地处理像 Future::get
这样的方法的结果。
Future<String> f = ...
switch (f.get()) {
case String s -> process(s);
case throws ExecutionException(var underlying) -> throw underlying;
case throws TimeoutException e -> cancel();
}
讨论
我们预计对此的反应最初会感到不舒服,因为历史上 try
语句是控制异常处理的唯一方法。 try
在其全部通用性方面仍然有明确的作用,但正如 switch
有利地处理了可以用更通用的 if-else
结构处理的情况的受限子集一样,允许它处理更通用的 try-catch
结构处理的案例的受限子集也同样有利。具体来说, switch
适合的情况是:评估一个表达式,然后根据评估该表达式的结果选择一条路径,同样适用于区分不成功的评估。客户端通常希望处理异常完成和成功完成,并且在单个结构中一致地这样做可能比将其分散到两个结构中更清晰且更不容易出错。
Java API 充满了可以产生结果或抛出异常的方法,例如 Future::get
。以这种方式编写 API 对 API 作者来说是自然的,因为他们可以以自然的方式处理计算;如果他们到达不想继续执行的点,他们可以 throw
一个异常,就像当他们到达计算完成的点时,他们可以 return
一个值一样。不幸的是,这种对 API 作者的便利性和一致性给 API 消费者带来了额外的负担;处理失败比处理成功情况更麻烦。允许客户端 switch
处理计算完成的所有方式可以弥合这种差距。
这并不是说 try-catch
已过时,就像 switch
使 if-else
过时一样。
当我们有一大块代码可能在多个点失败时,将所有来自该块的异常一起处理通常比在每个异常生成点处理它们更方便。
但是当我们将 try-catch
缩放到单个表达式时,它可能会变得很尴尬。这种影响在表达式 lambda 中最为严重,如果它们想要处理自己的异常,它们会经历重大的语法扩展。
本文内容最初来自 OpenJDK 邮件列表。我们鼓励您在那里关注或加入讨论。